Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

8장. 상속과 인터페이스

앞 장까지 우리는 클래스로 하나의 대상을 표현하는 법을 배웠습니다.

그런데 프로그램이 커지면
비슷한 클래스가 여럿 생기기 시작합니다.

이때 “공통된 부분은 한 번만 정의하고,
차이 나는 부분만 따로 만들 수는 없을까?” 하는 고민이 생깁니다.

이 고민을 풀어 주는 도구가 바로
상속(inheritance)과 인터페이스(interface)입니다.

이 장에서는 코틀린이 상속을 어떻게 다루는지,
그리고 인터페이스로 코드를 어떻게 유연하게 만드는지 살펴봅니다.

특히 마지막 절에서는
백엔드 개발에서 가장 중요한 습관 하나를 배웁니다.
바로 “구현이 아니라 인터페이스에 기대는” 설계입니다.


8.1 Kotlin 클래스는 기본적으로 final

Java와 다른 기본 정책

상속을 이야기하려면
먼저 코틀린의 독특한 기본 규칙을 알아야 합니다.

코틀린에서 클래스는
기본적으로 상속할 수 없습니다.

무슨 뜻인지 코드로 보겠습니다.

class Animal {
    fun breathe() {
        println("숨을 쉰다")
    }
}

class Dog : Animal()   // 오류: Animal은 상속할 수 없음

DogAnimal을 물려받으려 했지만
오류가 납니다.

Animal이 상속을 허락하지 않았기 때문입니다.

여기서 “상속할 수 없다“는 성질을
final(파이널)이라고 부릅니다.

final은 “더 이상 바꿀 수 없는, 마지막“이라는 뜻입니다.

코틀린의 모든 클래스는
특별히 열어 주지 않으면 final이다.

자바를 아는 분이라면 이 규칙이 낯설 것입니다.
자바는 정반대이기 때문입니다.

언어클래스 기본 상태상속하려면
자바상속 가능막으려면 final
코틀린상속 불가(final)열려면 open

자바는 “일단 열려 있고, 막고 싶으면 막는” 방식입니다.
코틀린은 “일단 닫혀 있고, 열고 싶을 때만 여는” 방식입니다.

왜 기본을 final로 정했을까

조금 불편해 보이는 이 규칙에는
분명한 이유가 있습니다.

상속은 강력하지만 위험하기도 합니다.

누군가 내 클래스를 물려받아
동작을 몰래 바꿔 버리면,
원래 만든 사람의 의도가 깨질 수 있습니다.

이런 상황을 비유하면 이렇습니다.

잘 만들어 둔 기계를 누가 마음대로 뜯어고치면,
원래대로 동작한다고 장담할 수 없습니다.

그래서 코틀린은 이렇게 생각합니다.

상속은 “이 클래스는 물려받아도 안전하다“고
만든 사람이 분명히 허락했을 때만 하자.

이 허락의 표시가 다음에 배울 open입니다.

open 키워드

클래스를 상속할 수 있게 열려면
클래스 앞에 open을 붙입니다.

open class Animal {
    fun breathe() {
        println("숨을 쉰다")
    }
}

class Dog : Animal()   // 이제 상속 가능

open은 말 그대로 “열다“라는 뜻입니다.

Animal 앞에 open을 붙였더니
이제 Dog가 문제없이 물려받습니다.

이렇게 코틀린에서는
“상속을 허락한 클래스“만 상속됩니다.

덕분에 코드를 읽는 사람은
open이 붙은 클래스를 보고
“아, 이건 물려받으라고 만든 거구나” 하고 바로 알 수 있습니다.

상속의 자세한 규칙은 다음 절에서 이어집니다.


8.2 상속

상속이란 무엇인가

상속(inheritance)은
한 클래스가 다른 클래스의 성질을 물려받는 것입니다.

물려주는 쪽을 부모 클래스(상위 클래스),
물려받는 쪽을 자식 클래스(하위 클래스)라고 부릅니다.

비유하면 이렇습니다.

부모가 가진 것을 자식이 그대로 물려받고,
거기에 자기만의 것을 더 얹는 것입니다.

코드로 보겠습니다.

open class Animal {
    fun breathe() {
        println("숨을 쉰다")
    }
}

class Dog : Animal() {
    fun bark() {
        println("멍멍")
    }
}

여기서 DogAnimal을 상속했습니다.

그래서 Dog
자기가 직접 만든 bark()뿐 아니라
부모에게 물려받은 breathe()도 쓸 수 있습니다.

val dog = Dog()
dog.breathe()   // 물려받은 기능
dog.bark()      // 자기만의 기능
숨을 쉰다
멍멍

breathe()를 다시 만들지 않았는데도
Dog가 쓸 수 있다는 점이 상속의 핵심입니다.

공통 기능을 부모에 한 번만 적어 두면,
자식들은 그것을 그냥 물려받습니다.

상속의 문법과 생성자 호출

코틀린에서 상속은 콜론(:)으로 표현합니다.

class Dog : Animal()

여기서 눈여겨볼 점은
부모 이름 뒤에 붙은 괄호 ()입니다.

이 괄호는 부모의 생성자를 부른다는 뜻입니다.
(생성자는 7장에서 다뤘습니다.)

자식을 만들 때
부모 부분도 함께 준비되어야 하므로,
부모의 생성자를 호출해 주는 것입니다.

부모가 값을 받는다면
이렇게 넘겨줍니다.

open class Animal(val name: String)

class Dog(name: String) : Animal(name)

Dog가 받은 name
그대로 부모 Animal에게 전달하는 모습입니다.

override로 기능 새로 정의하기

상속을 받으면
부모의 기능을 그대로 쓸 수도 있지만,
자식에 맞게 바꿔 쓰고 싶을 때도 있습니다.

예를 들어 모든 동물은 소리를 내지만,
소리는 저마다 다릅니다.

이렇게 부모의 기능을
자식이 새로 정의하는 것을
재정의(override, 오버라이드)라고 합니다.

여기에도 8.1의 “허락” 규칙이 그대로 적용됩니다.

  • 부모는 재정의를 허락하는 함수 앞에 open을 붙인다
  • 자식은 새로 정의하는 함수 앞에 override를 붙인다
open class Animal {
    open fun sound() {
        println("어떤 소리를 낸다")
    }
}

class Dog : Animal() {
    override fun sound() {
        println("멍멍")
    }
}

class Cat : Animal() {
    override fun sound() {
        println("야옹")
    }
}

이제 같은 sound()를 불러도
실제 객체에 따라 다르게 동작합니다.

val dog = Dog()
val cat = Cat()

dog.sound()   // 멍멍
cat.sound()   // 야옹
멍멍
야옹

함수뿐 아니라 클래스와 마찬가지로,
함수도 기본은 final입니다.
그래서 재정의를 허락하려면 반드시 open이 필요합니다.

자바에서는 @Override가 붙여도 그만, 안 붙여도 그만인
“권장 사항“이었습니다.

하지만 코틀린에서 override
반드시 붙여야 하는 “필수 규칙“입니다.

덕분에 이 함수가 부모의 것을 바꾼 것인지
한눈에 알 수 있습니다.

부모 기능을 함께 쓰기: super

재정의를 하더라도
부모의 원래 기능을 함께 쓰고 싶을 때가 있습니다.

이때는 super를 사용합니다.

super는 “부모“를 가리키는 말입니다.

open class Animal {
    open fun sound() {
        println("동물이 소리를 낸다")
    }
}

class Dog : Animal() {
    override fun sound() {
        super.sound()      // 부모의 기능을 먼저 실행
        println("멍멍")
    }
}
Dog().sound()
동물이 소리를 낸다
멍멍

super.sound()로 부모 기능을 먼저 실행하고,
그다음 자기만의 동작을 덧붙였습니다.

abstract: 뼈대만 정하는 클래스

이번에는 조금 다른 상황을 생각해 봅시다.

“모든 동물은 소리를 낸다“는 것은 확실합니다.
하지만 “동물 그 자체“의 소리가 무엇인지는 정할 수 없습니다.

개는 멍멍, 고양이는 야옹이지만
그냥 “동물“의 소리는 딱 정해지지 않습니다.

이럴 때 쓰는 것이
추상 클래스(abstract class)입니다.

추상(abstract)은
“구체적이지 않고 뼈대만 있는“이라는 뜻입니다.

abstract class Animal {
    abstract fun sound()      // 내용 없이 이름만 정함

    fun breathe() {
        println("숨을 쉰다")
    }
}

여기서 sound()에는
중괄호 { }가 없습니다.

“이런 기능이 있어야 한다“고 선언만 하고,
실제 내용은 자식에게 맡기는 것입니다.

이렇게 내용 없이 이름만 있는 함수를
추상 함수(abstract function)라고 합니다.

추상 클래스는 몇 가지 특징이 있습니다.

  • 추상 함수를 가질 수 있다(내용 없는 함수)
  • 일반 함수도 함께 가질 수 있다(breathe처럼)
  • 그 자체로는 객체를 만들 수 없다

마지막 특징이 중요합니다.

val animal = Animal()   // 오류: 추상 클래스는 객체를 만들 수 없음

“동물 그 자체“는 실체가 없으니
직접 만들 수 없는 것입니다.

대신 자식이 추상 함수를 채워야 합니다.

class Dog : Animal() {
    override fun sound() {
        println("멍멍")
    }
}

이제 Dog는 실체가 있으므로
객체를 만들 수 있습니다.

val dog = Dog()
dog.sound()     // 멍멍
dog.breathe()   // 숨을 쉰다

추상 클래스는
“공통 뼈대는 정해 두되,
구체적인 내용은 자식이 채우게 하는” 틀입니다.

참고로 추상 함수는
이미 “채워야만 하는” 것이라
open을 따로 붙이지 않아도 재정의됩니다.


8.3 인터페이스

인터페이스란 무엇인가

인터페이스(interface)는
“이런 기능이 있어야 한다“는 약속만 모아 둔 것입니다.

추상 클래스와 비슷해 보이지만,
더 순수하게 “약속“에만 집중한 개념입니다.

비유하면 이렇습니다.

인터페이스는 “해야 할 일의 목록“입니다.
목록을 받은 사람이 실제로 어떻게 할지는 각자 정합니다.

예를 들어 “소리를 낼 수 있다“는 능력을
인터페이스로 만들어 봅시다.

interface Soundable {
    fun sound()
}

interface라는 키워드로 정의합니다.

Soundable 안의 sound()
추상 함수처럼 이름만 있습니다.

즉, “소리를 낼 수 있는 것은
반드시 sound()를 갖춰야 한다“는 약속입니다.

인터페이스 구현하기

인터페이스의 약속을 실제로 지키는 것을
구현(implement, 구현하다)이라고 합니다.

문법은 상속과 똑같이 콜론(:)을 씁니다.

interface Soundable {
    fun sound()
}

class Dog : Soundable {
    override fun sound() {
        println("멍멍")
    }
}

여기서 한 가지 차이를 눈여겨보세요.

상속과 달리 인터페이스 이름 뒤에는
괄호 ()가 없습니다.

class Dog : Animal()      // 클래스 상속: 괄호 있음(생성자 호출)
class Dog : Soundable     // 인터페이스 구현: 괄호 없음

인터페이스는 생성자가 없기 때문입니다.
약속만 있을 뿐, 만들 실체가 없는 것입니다.

인터페이스의 함수를 채울 때도
override를 붙입니다.

약속을 지켰다는 표시라고 생각하면 쉽습니다.

여러 인터페이스 구현하기

인터페이스의 진짜 강점은 여기서 나옵니다.

클래스는 부모를 하나만 가질 수 있습니다.
하지만 인터페이스는 여러 개를 동시에 구현할 수 있습니다.

콤마(,)로 나열하면 됩니다.

interface Soundable {
    fun sound()
}

interface Swimmable {
    fun swim()
}

class Dog : Soundable, Swimmable {
    override fun sound() {
        println("멍멍")
    }

    override fun swim() {
        println("헤엄친다")
    }
}

Dog는 “소리를 낼 수 있고”
동시에 “헤엄칠 수도 있는” 존재가 되었습니다.

val dog = Dog()
dog.sound()   // 멍멍
dog.swim()    // 헤엄친다

여러 능력을 조합해서 붙일 수 있다는 점이
인터페이스를 강력하게 만듭니다.

상속은 “무엇인가(is-a)“를 표현하고,
인터페이스는 “무엇을 할 수 있는가(can-do)“를 표현합니다.

왜 클래스 상속은 하나만 되고
인터페이스는 여럿이 될까요?

클래스는 실제 데이터와 상태를 가지므로,
여럿을 물려받으면 충돌이 생기기 쉽습니다.

반면 인터페이스는 대체로 “약속“이라
여럿을 모아도 충돌이 적습니다.

기본 구현

인터페이스는 약속만 담는다고 했습니다.
그런데 코틀린에서는 예외가 있습니다.

인터페이스 안에서
함수의 내용을 미리 채워 둘 수 있습니다.

이것을 기본 구현(default implementation)이라고 합니다.

interface Soundable {
    fun sound()

    fun describe() {
        println("나는 소리를 낼 수 있다")
    }
}

sound()는 여전히 이름만 있지만,
describe()에는 내용이 들어 있습니다.

이렇게 내용이 있는 함수는
구현하는 쪽에서 다시 만들지 않아도 됩니다.

class Dog : Soundable {
    override fun sound() {
        println("멍멍")
    }
    // describe()는 만들지 않아도 됨
}
val dog = Dog()
dog.sound()      // 멍멍
dog.describe()   // 나는 소리를 낼 수 있다
멍멍
나는 소리를 낼 수 있다

describe()를 직접 만들지 않았는데도
기본 구현이 그대로 쓰였습니다.

물론 원하면 자식이 다시 정의할 수도 있습니다.

기본 구현 덕분에
꼭 필요한 것만 강제하고,
공통 동작은 인터페이스가 제공할 수 있습니다.

인터페이스와 추상 클래스, 무엇을 쓸까

둘은 비슷해 보이지만 쓰임이 다릅니다.
표로 정리해 보겠습니다.

구분추상 클래스인터페이스
개수 제한하나만 상속여러 개 구현 가능
상태(데이터)가질 수 있음원칙적으로 안 가짐
표현하는 것무엇인가(is-a)할 수 있는가(can-do)
생성자있음없음

지금은 이렇게 기억해 두면 충분합니다.

“이것도 저것도 할 수 있다“는 능력을 붙일 때는 인터페이스,
“공통된 뼈대와 데이터를 함께 물려줄” 때는 추상 클래스.

백엔드 실무에서는
인터페이스를 훨씬 자주 씁니다.

그 이유를 다음 절에서 살펴봅니다.


8.4 인터페이스로 의존성 분리하기

의존한다는 것의 의미

이제 이 장에서 가장 중요한 이야기입니다.

먼저 의존(dependency)이라는 말부터 풀어 봅시다.

어떤 코드 A가 동작하기 위해
다른 코드 B가 필요하다면,
“A는 B에 의존한다“고 말합니다.

비유하면 이렇습니다.

요리사(A)가 특정 칼(B)이 없으면
요리를 못 한다면,
요리사는 그 칼에 의존하는 것입니다.

프로그램에서는 이런 의존이 아주 많습니다.

예를 들어 “회원 관련 일을 처리하는 코드“는
“회원 정보를 저장하는 코드“에 의존합니다.

백엔드에서 흔히 쓰는 이름으로 표현하면 이렇습니다.

  • UserService : 회원 관련 처리를 담당
  • UserRepository : 회원 정보를 저장하고 꺼내는 역할

여기서 서비스(Service)는 “일 처리 담당”,
리포지토리(Repository)는 “저장소 담당” 정도로
지금은 이해하면 됩니다.
(자세한 역할은 나중에 스프링에서 다룹니다.)

이 둘의 의존 관계를 그림으로 보면 이렇습니다.

UserService
    ↓
UserRepository
    ↓
MemoryUserRepository

위에서 아래로 내려가는 화살표는
“위가 아래에 의존한다“는 뜻입니다.

UserServiceUserRepository에 기대고,
UserRepository는 실제 저장 방식인
MemoryUserRepository로 이어집니다.

여기서 핵심 질문이 나옵니다.

UserService는 저장을 “누구“에게 맡겨야 할까?
구체적인 구현일까, 아니면 인터페이스일까?

구현이 아닌 인터페이스에 의존하기

먼저 잘못된 방식을 보겠습니다.

UserService
구체적인 저장 방식에 직접 기대는 경우입니다.

// 구체적인 구현: 메모리에 저장
class MemoryUserRepository {
    private val storage = mutableMapOf<Long, String>()

    fun save(id: Long, name: String) {
        storage[id] = name
    }

    fun findName(id: Long): String? {
        return storage[id]
    }
}

// 서비스가 구현에 직접 의존
class UserService {
    private val repository = MemoryUserRepository()   // 딱 붙어 버림

    fun register(id: Long, name: String) {
        repository.save(id, name)
    }
}

mutableMapOf는 값을 담아 두는 저장소로,
지금은 “임시로 메모리에 저장한다” 정도로 보면 됩니다.
(자료구조는 6부에서 다룹니다.)

이 코드는 동작은 합니다.
하지만 문제가 있습니다.

UserServiceMemoryUserRepository
너무 단단히 붙어 버렸습니다.

나중에 저장 방식을
데이터베이스로 바꾸고 싶어지면,
UserService 안을 뜯어고쳐야 합니다.

이렇게 딱 붙어 있는 상태를
강한 결합(tight coupling)이라고 합니다.

이제 인터페이스를 도입해 봅시다.

“저장소는 이런 기능을 갖춰야 한다“는
약속부터 인터페이스로 정의합니다.

// 약속: 저장소가 갖춰야 할 기능
interface UserRepository {
    fun save(id: Long, name: String)
    fun findName(id: Long): String?
}

그리고 실제 구현이
이 약속을 지키게 합니다.

class MemoryUserRepository : UserRepository {
    private val storage = mutableMapOf<Long, String>()

    override fun save(id: Long, name: String) {
        storage[id] = name
    }

    override fun findName(id: Long): String? {
        return storage[id]
    }
}

이제 UserService
구체적인 구현이 아니라
인터페이스에만 기대게 만듭니다.

class UserService(
    private val repository: UserRepository   // 약속(인터페이스)에만 의존
) {
    fun register(id: Long, name: String) {
        repository.save(id, name)
    }

    fun findName(id: Long): String? {
        return repository.findName(id)
    }
}

여기서 UserService
자기 안에서 저장소를 직접 만들지 않습니다.

대신 생성자로
“약속을 지키는 저장소“를 받아 옵니다.

이렇게 필요한 것을 밖에서 넣어 주는 방식을
의존성 주입(Dependency Injection)이라고 합니다.

“주입“이란 밖에서 넣어 준다는 뜻입니다.

서비스는 “저장소가 어떻게 생겼는지” 모릅니다.
그저 “약속을 지키는 무언가“라는 것만 압니다.

이것이 “구현이 아니라 인터페이스에 의존하라“는
설계 원칙의 핵심입니다.

구현체를 교체하기

인터페이스에 의존하면
어떤 좋은 점이 생길까요?

가장 큰 이점은
구현체를 자유롭게 갈아 끼울 수 있다는 것입니다.

예를 들어 파일에 저장하는
새로운 구현을 하나 더 만들어 봅시다.

class FileUserRepository : UserRepository {
    override fun save(id: Long, name: String) {
        println("파일에 저장: $id, $name")
    }

    override fun findName(id: Long): String? {
        println("파일에서 조회: $id")
        return "파일에서 찾은 이름"
    }
}

UserService는 전혀 건드리지 않았습니다.

그런데도 저장 방식을
자유롭게 바꿀 수 있습니다.

// 메모리 방식으로 사용
val service1 = UserService(MemoryUserRepository())

// 파일 방식으로 교체
val service2 = UserService(FileUserRepository())

넣어 주는 구현체만 바꿨을 뿐,
UserService의 코드는 한 줄도 바뀌지 않았습니다.

앞의 다이어그램으로 돌아가 보면
그 의미가 분명해집니다.

UserService
    ↓
UserRepository        (약속: 인터페이스)
    ↓
MemoryUserRepository  (실제 구현, 언제든 교체 가능)

UserService는 가운데의 UserRepository라는
약속만 바라봅니다.

맨 아래의 실제 구현이 무엇으로 바뀌든
위쪽은 영향을 받지 않습니다.

이렇게 서로 느슨하게 연결된 상태를
느슨한 결합(loose coupling)이라고 합니다.

느슨하게 연결될수록
바꾸기 쉽고, 확장하기 쉬운 코드가 됩니다.

테스트 가능한 코드 만들기

인터페이스 분리의 또 다른 큰 장점은
테스트가 쉬워진다는 것입니다.

테스트(test)란
“코드가 제대로 동작하는지 미리 확인하는 것“입니다.

UserService가 잘 동작하는지
확인한다고 생각해 봅시다.

만약 서비스가
진짜 데이터베이스에 딱 붙어 있다면,
테스트할 때마다 데이터베이스가 필요합니다.

데이터베이스를 켜고, 연결하고,
느리고, 준비도 번거롭습니다.

하지만 인터페이스에 의존한다면,
테스트용 가짜 저장소를 만들어 넣으면 됩니다.

이런 가짜 구현을
가짜 객체(fake) 또는 테스트 대역이라고 부릅니다.

// 테스트용 가짜 저장소
class FakeUserRepository : UserRepository {
    private val storage = mutableMapOf<Long, String>()

    override fun save(id: Long, name: String) {
        storage[id] = name
    }

    override fun findName(id: Long): String? {
        return storage[id]
    }
}

이제 이 가짜 저장소를 넣어
서비스를 가볍게 확인할 수 있습니다.

fun main() {
    val service = UserService(FakeUserRepository())

    service.register(1L, "홍길동")
    val name = service.findName(1L)

    println(name)   // 홍길동
}
홍길동

데이터베이스 없이도
UserService가 잘 동작하는지 확인했습니다.

빠르고, 간단하고, 어디서나 돌아갑니다.

인터페이스에 의존하면
진짜 대신 가짜를 끼워 넣기 쉬워집니다.
그래서 테스트가 쉬워집니다.

정리해 봅시다.
인터페이스로 의존성을 분리하면 세 가지를 얻습니다.

  • 구현체를 자유롭게 교체할 수 있다
  • 코드가 느슨하게 연결되어 바꾸기 쉬워진다
  • 가짜 구현으로 테스트하기 쉬워진다

이 세 가지는
백엔드 개발 내내 계속 따라다니는 이점입니다.

특히 나중에 스프링을 배울 때,
스프링이 바로 이 “의존성 주입“을
자동으로 해 준다는 것을 알게 될 것입니다.

지금 이 원칙을 몸에 익혀 두면
스프링이 훨씬 쉽게 이해됩니다.


8장을 마치며

이 장에서 우리는 다음을 배웠습니다.

  • 코틀린 클래스는 기본이 final이며,
    상속하려면 open으로 열어야 한다는 점
  • 상속에서 override로 기능을 다시 정의하고,
    super로 부모 기능을 함께 쓰는 방법
  • abstract로 뼈대만 정해 두고
    자식이 내용을 채우게 하는 추상 클래스
  • 인터페이스로 “할 수 있는 능력“을 약속하고,
    여러 인터페이스를 동시에 구현하는 방법
  • 인터페이스의 기본 구현으로
    공통 동작을 미리 제공하는 방법
  • 구현이 아니라 인터페이스에 의존해서
    교체 가능하고 테스트하기 쉬운 코드를 만드는 설계

특히 마지막 절의 생각은 오래 기억해 주세요.

“구체적인 구현“이 아니라
“약속(인터페이스)“에 기대라.

이 한 문장이
좋은 백엔드 코드의 출발점입니다.

다음 장에서는
코틀린이 자랑하는 편리한 클래스들,
데이터 클래스와 관련 기능들을 살펴보겠습니다.